Dubbo反序列化RCE利用之新拓展面 - Dubbo Rouge攻击客户端

0x01 前言

前段时间写了篇关于Dubbo在默认dubbo协议下,使用hessian2序列化方式的利用讲解文章《dubbo源码浅析:默认反序列化利用之hessian2》,我发现网络上并没有存在过讲解这方面利用的文章,其实这么简单,想必很多大佬在这之前已经知道了…

文章发了之后,我觉得应该很多公司都对其Dubbo服务的安全隐患进行了排查,对于一些没有安全审查能力的公司,可能会留下了比较大的安全隐患,故而,我又写了篇关于Hessian2反序列化安全加固的文章《dubbo反序列化问题-Hessian2安全加固和修复》 ,原理也非常之简单,其实就是加入黑名单,相对于原生Java反序列化原理差不多,只不过原生Java反序列化已经有实现了,只要配置系统环境变量java.serialization去配置Filter就好了。

前面,所讲解的都是Dubbo服务端的攻击手法,那么,我们有没有办法去攻击Dubbo客户端呢?既然Dubbo服务端能接收客户端发来的序列化数据进行反序列化,造成RCE,那么对于服务端的恶意序列化数据响应,必然也会造成客户端反序列化RCE吧?

带着这些疑问,我对其源码进行了一番研究,并且进行了实验。我们知道,使用Dubbo的时候,我们一般使用Zookeeper作为注册中心,也可以不使用注册中心,选择直连的方式,参考官方文档

若客户端选择了直连的方式,我们就可以类似Mysql Rouge、Redis Rouge的方式,去部署一个恶意的服务,在客户端连接上来后,返回恶意的序列化数据,造成客户端反序列化RCE。具体实现,后面我会在github开源的项目learn-java-bug放出,在dubbo这个module的com.threedr3am.bug.dubbo.rouge包下,具体使用方式,有两种反序列化攻击hessian2和原生java,具体选用随意,只要在攻击代码中修改一下以下代码即可:

1
2
3
4
5
String zookeeperUri = "127.0.0.1:2181";//直连模式下,无需关心这个配置
String rougeHost = "127.0.0.1";//当前恶意服务所在ip
int rougePort = 33336;//当前恶意服务通讯端口

new DNSURL().startRougeServer(zookeeperUri, rougeHost, rougePort, bytes, false);//直连模式下,startRougeServer方法最后一个attackRegister参数必须为false

上面只是直连的攻击手法,个人觉得比较low的,因为你没办法控制客户端的配置,既然都不可控,就谈不上直连攻击了。那么,有没有更容易的利用手法呢?我们能不能直接连接客户端,发送恶意序列化数据?

经过一番源码论证以及试验,也行不通,因为客户端默认也是使用netty,和服务端建立tcp长连接,也就是说,客户端不监听tcp连接,它只会主动建立连接,那么,这里是不是可以考虑tcp的攻击?是不是可以遍历seq,去伪造数据包?我这里不对其进行考虑和研究,当然是因为有更好的利用手法啦!


0x02 dubbo服务集群

为了避免单点故障,现在的应用通常至少会部署在两台服务器上。对于一些负载比较高的服务,会部署更多的服务器。这样,在同一环境下的服务提供者数量会大于1。对于服务消费者来说,同一环境下出现了多个服务提供者。这时会出现一个问题,服务消费者需要决定选择哪个服务提供者进行调用。另外服务调用失败时的处理措施也是需要考虑的,是重试呢,还是抛出异常,亦或是只打印异常等。为了处理这些问题,Dubbo 定义了集群接口 Cluster 以及 Cluster Invoker。集群 Cluster 用途是将多个服务提供者合并为一个 Cluster Invoker,并将这个 Invoker 暴露给服务消费者。这样一来,服务消费者只需通过这个 Invoker 进行远程调用即可,至于具体调用哪个服务提供者,以及调用失败后如何处理等问题,现在都交给集群模块去处理。集群模块是服务提供者和服务消费者的中间层,为服务消费者屏蔽了服务提供者的情况,这样服务消费者就可以专心处理远程调用相关事宜。比如发请求,接受服务提供者返回的数据等。这就是集群的作用。

上面是抄摘Dubbo官方文档的一番描述,Dubbo是一个具备高可用特性的RPC框架,为了高可用的特性,若单点部署,出现故障就跟高可用毛线关系没有了,所以,一般很多企业都会对其进行生产可用性的调整,无非就是集群部署,部署多个节点,那么,这个时候,若是使用注册中心的方式,就能达到动态的扩容和下线了,因为集群服务的每一台机器,都把自己的一些连接和配置信息放在了Zookeeper(其它的注册中心也一样,类似nacos等等)上了。

既然是通过Zookeeper去注册新的服务,让客户端去发现,进行连接使用。那么,这就是一个利用突破点,若我们把前面所说的,我们的dubbo rouge恶意服务注册到Zookeeper,这样,在客户端进行负载均衡的时候,就会有几率连接到我们的恶意服务,从而使dubbo客户端能接收并反序列化我们的恶意序列化数据,最终RCE,更绝的是,我们可以把其它机器的注册信息删除了,那么客户端就剩下我们的恶意服务可以连接了,一打一个准。


0x03 恶意服务注册

上一小节说了,把我们的恶意服务注册到Zookeeper来,经过一番调试,可以看到,默认情况下,以某service服务的全限定名

1
com.threedr3am.learn.server.boot.DemoService

为例,存在Zookeeper的路径为:

1
/dubbo/{com.threedr3am.learn.server.boot.DemoService}/providers/

对于每一个dubbo服务,都会在providers目录下,新建一个children path,一个进行了URLEncode的path,例:

1
2
3
4
5
6
7
dubbo%3A%2F%2F127.0.0.1%3A20881%2Fcom.threedr3am.learn.server.boot.D
emoService%3Factives%3D5%26anyhost%3Dtrue%26application%3Dservice-pr
ovider%26deprecated%3Dfalse%26dubbo%3D2.0.2%26dynamic%3Dtrue%26gener
ic%3Dfalse%26interface%3Dcom.threedr3am.learn.server.boot.DemoServic
e%26methods%3Dhello%26pid%3D28092%26release%3D2.7.5%26retries%3D3%26
revision%3D1.0%26side%3Dprovider%26time
out%3D3000%26timestamp%3D1582005204823%26version%3D1.0

可以看到,上面的信息,包含了dubbo服务所在的ip和port

既然如此,那么我们只要加入一个同样的path,指向我们的恶意服务即可。但是,我们怎么确定service名称呢?其实,并不需要,我们只要遍历到providers目录,从providers取出已有的一个path,对其进行修改成我们恶意服务的ip和port后添加就行了,具体实现代码就不放了。

并且,更绝的是,我们把其他的注册信息删除了,因此,客户端根据负载均衡,只能选择唯一的,也就是我们的恶意服务进行RPC了,在对其进行了测试之后,效果很好,客户端的RPC请求里面就发过来了,那么我们就可以进行下一步了攻击了。


0x04 dubbo服务治理

image

上图所示为dubbo的服务治理架构图,在dubbo2.7以后,多了三大feature,其中之一为元数据中心,习惯使用dubbo的人一般都比较了解,当集群部署dubbo的服务,每一个dubbo服务都会把自身信息注册至注册中心,也就是途中,dubbo客户端可访问的注册中心。

随着注册信息的增加,数据量的膨胀,会导致注册中心不断增大网络开销,直接造成了服务地址推送慢等负面影响,因为在2.7版本以前,注册中心存放在大量注册信息无关的信息,因此在2.7以后,为了避免数据量的膨胀导致注册中心不断增大网络开销,新增了元数据中心,用于存储此类与注册无关的信息,其中包含了服务的方法列表以及参数列表等等。

但dubbo开发者,为了降低开发者使用dubbo的难度,还是存在着把部分信息存在了注册中心,具体是何信息呢?我们以zookeeper为例,展开探究。


0x05 控制客户端序列化类型

上一小节,讲述了如何去把恶意服务注册到注册中心,但是,我们在默认情况下,一般都是使用Hessian2的序列化方式,它的可利用的gadget有点少,因此,利用受限还是挺严重的。

若是客户端使用的是原生Java序列化方式,那么,我们的攻击威力,瞬间就大增了,但是,一般情况下,很多Dubbo的使用者,他们都是使用默认缺省配置,也就是Hessian2的序列化方式,凉凉…

但我们回想一下,在使用dubbo的时候,我们是不是只听过服务端配置序列化类型,而没听过使用客户端配置序列化类型?

在回顾我们前面所说的,服务端往注册中心写的信息:

1
2
3
4
5
6
7
dubbo%3A%2F%2F127.0.0.1%3A20881%2Fcom.threedr3am.learn.server.boot.D
emoService%3Factives%3D5%26anyhost%3Dtrue%26application%3Dservice-pr
ovider%26deprecated%3Dfalse%26dubbo%3D2.0.2%26dynamic%3Dtrue%26gener
ic%3Dfalse%26interface%3Dcom.threedr3am.learn.server.boot.DemoServic
e%26methods%3Dhello%26pid%3D28092%26release%3D2.7.5%26retries%3D3%26
revision%3D1.0%26side%3Dprovider%26time
out%3D3000%26timestamp%3D1582005204823%26version%3D1.0

对其进行decode:

1
2
3
4
5
6
dubbo://127.0.0.1:20881/com.threedr3am.learn.server.boot.DemoService
?actives=5&anyhost=true&application=service-provider&deprecated=fals
e&dubbo=2.0.2&dynamic=true&generic=false&interface=com.threedr3am.le
arn.server.boot.DemoService&methods=hello&pid=28092&release=2.7.5&re
tries=3&revision=1.0&side=provider&timeout=
3000&timestamp=1582005204823&version=1.0

好像并没有发现有什么异处。

在进一步对序列化和反序列化相关代码审计之后,我发现,客户端的序列化方式,居然是根据服务端的配置来选择,那么,我们是不是就对其序列化类型可控了?

在我通过对服务端序列化类型配置为原生java类型后,我发现。

1
2
3
4
5
6
dubbo://127.0.0.1:20881/com.threedr3am.learn.server.boot.DemoService
?actives=5&anyhost=true&application=service-provider&deprecated=fals
e&dubbo=2.0.2&dynamic=true&generic=false&interface=com.threedr3am.le
arn.server.boot.DemoService&methods=hello&pid=28092&release=2.7.5&re
tries=3&revision=1.0&serialization=java&side=provider&timeout=
3000&timestamp=1582005204823&version=1.0

zookeeper的注册信息中,居然多出了一个配置项

1
2
3
4
没错,默认情况下,这个配置是缺省的,缺省情况下,客户端会选择hessian2的序列化方式,若在注册信息中,加入该配置,客户端在读取该注册信息并连接上我们的dubbo服务后,它就选择了原生java的序列化方式了。

因此,我们就能通过追加serialization参数去自由选择客户端的序列化方式了,例如
```serialization=java

别忘了,根据dubbo协议以及源码的判断,还得把恶意响应包的头部的序列化标识id修改为原生Java的id,具体数字看:org.apache.dubbo.common.serialize.Constants

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package org.apache.dubbo.common.serialize;

public interface Constants {
byte HESSIAN2_SERIALIZATION_ID = 2;
byte JAVA_SERIALIZATION_ID = 3;
byte COMPACTED_JAVA_SERIALIZATION_ID = 4;
byte FASTJSON_SERIALIZATION_ID = 6;
byte NATIVE_JAVA_SERIALIZATION_ID = 7;
byte KRYO_SERIALIZATION_ID = 8;
byte FST_SERIALIZATION_ID = 9;
byte NATIVE_HESSIAN_SERIALIZATION_ID = 10;
byte PROTOSTUFF_SERIALIZATION_ID = 12;
byte AVRO_SERIALIZATION_ID = 11;
byte GSON_SERIALIZATION_ID = 16;
byte PROTOBUF_JSON_SERIALIZATION_ID = 21;
}

0x06 发送恶意序列化数据

既然,我们可以注册恶意服务,并且还能控制客户端的反序列化方式,那么,只要注册中心可控,我们就能畅通无阻,而且,这种打法,比打服务端更销魂,打服务端,我们得针对特定ip、port去打,若使用者更换了端口号,我们还得去扫出来,说不定就触发蜜罐了。

而这种方式,当然,注册中心的端口可能也会被定制的修改掉,但是比起dubbo,根据我的个人经验,我有理由相信概率更低。so,现在我们只要等客户端送上门就行了,哈哈,我终于理解 rouge 中文意指 胭脂 的意思了…

脚本编写,我这边以
commons-collections:commons-collections:3.2.1
的gadget为例。

依赖(这里需要特别注意Zookeeper的版本,后面等开放项目learn-java-bug中的利用demo后,再慢慢加上其它注册中心利用的demo)

参考